<template>
  <div class="window-height relative-position row flex-center q-pa-lg">
    <div
      :class="fullScreen ? '' : 'col-xl-9 col-lg-10 col-md-12 col-sm-12'"
      class="col-12 shadow-6 column no-wrap relative-position"
      :style="fullScreen ? { height: '100%' } : { height: '54vh', minHeight: '30rem' }"
      style="border-radius: 6px"
    >
      <div class="col no-wrap row">
        <div style="overflow-y: auto; overflow-x: hidden" class="col column no-wrap relative-position q-pb-md q-mt-none q-pl-lg q-pr-md q-gutter-y-md treeCtx">
          <div class="row no-wrap q-gutter-x-md items-center">
            <q-select v-model="root.type" dense outlined :options="rootTypes" prefix="数据类型" @update:model-value="resetConfig" />
            <InputWithSuggestion v-model:model-value="root.mock" clearable style="width: 6.6rem" :list="getSuggestList(null, root.type)" dense outlined placeholder="mock规则" />
            <q-space />
            <div><q-btn dense padding="0.21rem 1.2rem" glossy label="预置" color="primary" @click="preset" /></div>
            <q-toggle v-model="useSmartInput" label="智能填充" :icon="mdiAutoFix" />
            <q-btn flat label="mock说明" color="primary" @click="showMockTip" />
          </div>
          <Draggable
            v-model="configList"
            item-key="id"
            style="overflow-y: auto; overflow-x: hidden"
            handle=".dragBar"
            animation="300"
            :ghost-class="$style.ghost"
            :chosen-class="$style.chosen"
            class="col column no-wrap relative-position q-pb-md q-mt-none q-gutter-y-md treeCtx"
            @start="onDragStart"
            @end="onDragEnd"
          >
            <template #item="{ element: row, index: i }">
              <div v-show="!hideNodeIds.includes(row.id)" :key="row.id" :ref="`node_${row.id}`" class="row no-wrap q-gutter-x-md relative-position" :class="{ toMove: row.toMove }">
                <div class="row no-wrap" :style="{ width: widthMap.attrWidth }">
                  <div class="row justify-end" :style="{ width: `${widthMap.indexStep * (row.level || 0)}rem` }">
                    <q-btn
                      v-if="ifShowExpBtn(row)"
                      dense
                      rounded
                      flat
                      :style="{ width: `${widthMap.indexStep}rem` }"
                      :icon="row.expanded ? mdiMenuDown : mdiMenuRight"
                      size="md"
                      @click="row.expanded = !row.expanded"
                    />
                  </div>
                  <q-input
                    :ref="`${row.id}attrInput`"
                    v-model="row.attr"
                    class="col noPRInput hoverAppendInput leftInput"
                    :error="treeInfo.repeatIdList.includes(row.id)"
                    dense
                    hide-bottom-space
                    hide-hint
                    outlined
                    :readonly="row.noAttr"
                    :placeholder="row.noAttr ? '索引' : '属性名'"
                    @keypress.enter="fireAdd(i)"
                    @update:model-value="setDefMock(row)"
                  >
                    <template #append>
                      <q-icon size="md" class="dragBar" style="cursor: move" :name="mdiDrag" />
                    </template>
                  </q-input>
                  <q-input
                    v-model="row.comment"
                    class="col rightInput"
                    dense
                    hide-bottom-space
                    hide-hint
                    outlined
                    placeholder="说明（注释）"
                    @keypress.enter="fireAdd(i)"
                  />
                </div>
                <q-select v-model="row.type" style="width: 5.1rem" dense hide-dropdown-icon hide-bottom-space hide-hint outlined :options="dataTypes" @update:model-value="onTypeChange(row)" />
                <InputWithSuggestion
                  :ref="`${row.id}mockInput`"
                  v-model:model-value="row.mock"
                  clearable
                  :list="getSuggestList(row)"
                  class="col"
                  dense
                  outlined
                  :placeholder="objectTypes.includes(row.type.value) ? 'mock规则' : 'mock占位符'"
                  @keypress.enter.prevent="fireAdd(i)"
                />
                <div class="row flex-center">
                  <ConfirmPopButton :handle-fn="() => doDel(row, i)" round flat :disable="configList.length === 1" :pop="{ round: true, outline: true }" />
                  <q-btn v-if="objectTypes.includes(row.type.value)" :disable="!canAdd(row)" round :icon="mdiPlus" color="primary" flat size="sm">
                    <q-menu anchor="bottom end" self="top right">
                      <q-list separator dense>
                        <q-item v-close-popup clickable @click="addNewAttr(i, true)">
                          <q-item-section>子节点</q-item-section>
                        </q-item>
                        <q-item v-close-popup clickable @click="addNewAttr(i)">
                          <q-item-section>兄弟节点</q-item-section>
                        </q-item>
                      </q-list>
                    </q-menu>
                  </q-btn>
                  <q-btn v-else :disable="!canAdd(row)" round :icon="mdiPlus" color="primary" flat size="sm" @click="addNewAttr(i)" />
                </div>
              </div>
            </template>
          </Draggable>
        </div>
        <div :class="{ 'col-5': expanded }" :style="expanded ? null : { width: '0' }" class="col-5 relative-position fullHeightArea" style="border-left: 1px solid #e5e5e5">
          <div class="absolute column q-gutter-y-xs full-height" style="z-index: 12; top: -15px; transform: translateX(-50%)">
            <q-btn size="sm" round color="secondary" :icon="fullScreen ? mdiFullscreenExit : mdiFullscreen" class="q-mb-sm" @click="fullScreen = !fullScreen" />
            <q-btn size="sm" round color="secondary" :icon="expanded ? mdiArrowCollapseRight : mdiChevronLeft" @click="expanded = !expanded" />
            <q-btn v-show="mockJson" size="sm" round color="secondary" :icon="mdiContentCopy" @click="copyResult" />
            <q-space />
            <q-btn v-show="canDoMock" class="q-mb-sm" size="sm" round :icon="MockImg" @click="showMockConfig" />
            <q-btn :disable="!inputJson" size="sm" round color="primary" :icon="mdiChevronDoubleLeft" @click="generateConfig()" />
            <q-btn :disable="!canDoMock" size="sm" round color="primary" :icon="mdiChevronDoubleRight" @click="doMock" />
          </div>
          <q-input
            v-show="expanded"
            v-model="mockJson"
            filled
            type="textarea"
            input-style="line-height: 1.5rem"
            class="absolute-full q-py-md q-px-lg noPRInput"
            placeholder="Mock结果将展示在这，或从这导入JSON示例"
          />
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { uid } from 'quasar'
import Mock from 'better-mock'
import JSON5 from 'json5'
import { zipObjectDeep } from 'lodash-es'
import Draggable from 'vuedraggable-es'
import {
  mdiArrowCollapseRight,
  mdiAutoFix,
  mdiChevronDoubleLeft,
  mdiChevronDoubleRight,
  mdiChevronLeft,
  mdiContentCopy,
  mdiDrag,
  mdiFullscreen,
  mdiFullscreenExit,
  mdiMenuDown,
  mdiMenuRight,
  mdiPlus,
} from '@quasar/extras/mdi-v6'
import { dataTypes, getMockExpressListByAttr, getSuggestList, objectTypes, rootTypes, showMockTip } from '@/common/mocker'
import { arrayToMap } from '@/common/utils'

const rootKey = 'root'

const firstObjectAttr = {
  attr: '',
  type: dataTypes[0],
  mock: '',
  expanded: true,
}
let toMoveInfo

export default {
  name: 'JsonByMock',
  components: {
    Draggable,
  },
  data() {
    return {
      mockJson: null,
      fullScreen: false,
      useSmartInput: true,
      expanded: true,
      dataTypes,
      rootTypes,
      objectTypes,
      root: {
        type: rootTypes[0],
        mock: '',
      },
      configList: [ this.getNewNode() ],
      MockImg: 'img:static/img/icons/favicon-128x128.png',
      mdiArrowCollapseRight,
      mdiAutoFix,
      mdiChevronDoubleLeft,
      mdiChevronDoubleRight,
      mdiChevronLeft,
      mdiContentCopy,
      mdiDrag,
      mdiFullscreen,
      mdiFullscreenExit,
      mdiMenuDown,
      mdiMenuRight,
      mdiPlus,
    }
  },
  computed: {
    widthMap() {
      let indexStep = 2
      let attrWidth = '27rem'
      if (this.expanded) {
        indexStep = 1.5
        attrWidth = '21rem'
      }
      return {
        indexStep,
        attrWidth,
      }
    },
    treeInfo() {
      const pathSet = {}
      const idPathMap = {}
      let repeatIdList = []
      let hasError = false
      const pPathCountMap = {}
      const idNodeMap = arrayToMap(this.configList, 'id')
      for (const node of this.configList) {
        const {
          attr,
          id,
          type: { value: type },
          noAttr,
          pId,
          mock,
        } = node
        let pNode
        if (pId) {
          pNode = idNodeMap.get(pId)
        }
        else {
          pNode = this.root
        }
        let path = (pNode.path ? `${pNode.path}.` : '') + attr

        const pIdKey = pId || 'root'
        if (pIdKey in pPathCountMap) {
          pPathCountMap[pIdKey] += 1
        }
        else {
          pPathCountMap[pIdKey] = 0
        }
        if (pNode?.type.value === 'array') {
          path = `${pNode.path ? pNode.path : ''}[${pPathCountMap[pIdKey]}]`
        }

        if (pathSet[path] && !repeatIdList.length) {
          repeatIdList = [ pathSet[path], id ]
        }
        else if (!noAttr) {
          pathSet[path] = id
        }

        if (objectTypes.includes(type)) {
          path += mock ? `|${mock}` : ''
        }

        node.path = path
        idPathMap[id] = path

        if (!hasError) {
          const can = this.canAdd(node)
          if (!can) {
            hasError = true
          }
        }
      }
      return {
        repeatIdList,
        idPathMap,
        hasError,
      }
    },
    hideNodeIds() {
      const hideIds = new Set()
      const hideNodes = this.configList.filter(({ expanded }) => expanded === false)

      const fn = ({ id }) => {
        this.configList
          .filter(({ pId }) => pId === id)
          .forEach(({ id, type: { value: type } }) => {
            hideIds.add(id)
            if (objectTypes.includes(type)) {
              fn({ id })
            }
          })
      }
      hideNodes.forEach(fn)
      return [ ...hideIds ]
    },
    inputJson() {
      let json
      try {
        if (this.mockJson) {
          json = JSON5.parse(this.mockJson)
        }
      }
      catch (e) {
        console.error(e)
      }
      return json
    },
    canDoMock() {
      const { repeatIdList, hasError } = this.treeInfo
      return !repeatIdList.length && !hasError
    },
  },
  watch: {
    configList(v) {
      if (!v.length) {
        this.resetConfig()
      }
    },
  },
  methods: {
    preset() {
      const initJson = JSON5.parse(`{
        "code": "0",
        "msg": "消息",
        "resultData": {
          "list": [
            {
              "id": "主键"
            }
          ]
        }
      }`)
      this.generateConfig(initJson)
    },
    doDel(node, i) {
      const { id: _id } = node
      const list = this.configList
      const doDelById = id => {
        const children = list.filter(({ pId }) => pId === id)
        children?.forEach(({ type: { value: type }, id }) => {
          if (objectTypes.includes(type)) {
            doDelById(id)
          }
          else {
            list.splice(
              list.findIndex(({ id: nId }) => nId === id),
              1,
            )
          }
        })
        if (typeof i === 'undefined') {
          if (_id === id) {
            return
          }
        }
        list.splice(
          list.findIndex(({ id: nId }) => nId === id),
          1,
        )
      }
      doDelById(_id)
    },
    resetConfig({ value: type }) {
      const mock = this.getSuggestList({ type })?.[0]
      const config = this.getNewNode()
      if (type === 'array') {
        config.noAttr = true
      }
      this.configList = [ config ]
      this.root.mock = ''
      if (typeof mock !== 'undefined') {
        this.root.mock = mock
      }
    },
    copyResult() {
      this.$copy(this.mockJson)
    },
    getSuggestList(row, rootType) {
      return getSuggestList(row, rootType?.value)
    },
    getNewNode(pType, pId, level = 0) {
      const r = {
        ...firstObjectAttr,
        id: uid(),
        pId,
        level,
      }
      if (pType === 'array') {
        r.noAttr = true
      }
      return r
    },
    addNewAttr(index, isSubNode) {
      const list = this.configList
      const { type, noAttr, id, pId: pNodeId, level } = list[index]
      let newIndex = index + 1
      let pId = pNodeId
      let pType = noAttr ? 'array' : 'object'
      let curLevel = level
      if (isSubNode) {
        pType = type.value
        pId = id
        curLevel = level + 1
      }
      else {
        let lastChildNodeId
        const doDelById = id => {
          const children = list.filter(({ pId }) => pId === id)
          children?.forEach(({ type: { value: type }, id }) => {
            if (objectTypes.includes(type)) {
              doDelById(id)
            }
            else {
              lastChildNodeId = id
            }
          })
        }
        doDelById(id)
        if (lastChildNodeId) {
          newIndex = list.findIndex(({ id }) => id === lastChildNodeId) + 1
        }
      }

      const newNode = this.getNewNode(pType, pId, curLevel)
      list.splice(newIndex, 0, newNode)
      this.$nextTick(() => {
        const { id, noAttr } = newNode
        const refs = this.$refs
        refs[`node_${id}`].scrollIntoView({
          block: 'start',
          behavior: 'smooth',
        })
        refs[id + (noAttr ? 'mockInput' : 'attrInput')].focus()
      })
    },
    ifShowExpBtn({ type: { value: type }, id }) {
      return objectTypes.includes(type) && this.configList.some(({ pId }) => pId === id)
    },
    onTypeChange(row) {
      row.mock = ''
      this.doDel(row)
      this.setDefMock(row)
    },
    setDefMock(row) {
      if (!this.useSmartInput) {
        return
      }
      this.$nextTick(() => {
        const suggestion = this.getSuggestList(row)
        if (typeof suggestion?.[0] !== 'undefined') {
          row.mock = suggestion?.[0]
        }
      })
    },
    canAdd({ attr, mock, id, noAttr, type: { value: type }, pId }) {
      if (noAttr) {
        if (type === 'object' || type === 'array') {
          return true
        }
        return !!mock
      }
      const hasSame = this.configList.some(d => d.pId === pId && d.id !== id && !d.noAttr && d.attr === attr)
      if (type === 'object' || type === 'array') {
        if (this.configList.length === 1) {
          return !!attr
        }
        return !hasSame
      }
      const isOk = !!attr && !!mock
      return isOk && !hasSame
    },
    getMockConfig() {
      const pathArr = []
      const mockArr = []
      this.configList.forEach(({ path, mock }) => {
        pathArr.push(path)
        mockArr.push(mock)
      })
      const {
        type: { value: type },
        mock,
      } = this.root
      const isArr = type === 'array'
      const isObj = !isArr
      const rootPrev = rootKey + (mock ? `|${mock}` : '')
      pathArr.forEach((d, i) => {
        pathArr[i] = rootPrev + (isObj ? '.' : '') + d
      })
      pathArr.unshift(rootPrev)
      mockArr.unshift('')
      return zipObjectDeep(pathArr, mockArr)
    },
    showMockConfig() {
      const mockConfig = this.getMockConfig()
      this.mockJson = JSON.stringify(this.root.type.value === 'array' ? mockConfig : mockConfig[rootKey], null, 4)
      this.expanded = true
    },
    commentify(mockJson) {
      const configList = this.configList
      const lines = mockJson.split('\n')
      const newLines = []
      lines.forEach(line => {
        const [ , lineAttr ] = line.match(/"(.+)":/) || []
        let commented
        if (lineAttr) {
          const { index } = line.match(/[^\s]/) || {}
          if (typeof index !== 'undefined') {
            const attrLevel = index / 2 - 1
            const sameLevels = configList.filter(({ level }) => level === attrLevel)
            const attrConfig = sameLevels?.find(({ attr }) => attr === lineAttr)
            if (attrConfig?.comment) {
              newLines.push(`${line} //${attrConfig.comment}`)
              commented = true
            }
          }
        }
        if (!commented) {
          newLines.push(line)
        }
      })
      return newLines.join('\n')
    },
    doMock() {
      const mockConfig = this.getMockConfig()
      const mockObj = Mock.mock(mockConfig)
      const mockJson = JSON.stringify(mockObj[rootKey], null, 2)
      this.mockJson = this.commentify(mockJson)
      this.expanded = true
    },
    analysisValue(val, attr) {
      const type = this.getTypeof(val)
      let mock
      if (this.useSmartInput) {
        mock = getMockExpressListByAttr(attr)[0]
        if (objectTypes.includes(type)) {
          mock = rootTypes.find(d => d.value === type).mockTip[0]
        }
      }
      return {
        type: dataTypes.find(({ value }) => value === type) || dataTypes[0],
        mock,
      }
    },
    getTypeof(val) {
      let type = typeof val
      if (type === 'object' && Array.isArray(val)) {
        type = 'array'
      }
      return type
    },
    generateConfig(json) {
      json = json || this.inputJson
      const configList = []
      const loop = (objOrArr, pId, level = -1) => {
        level++
        const pType = this.getTypeof(objOrArr)
        if (pType === 'object') {
          for (const attr in objOrArr) {
            const curVal = objOrArr[attr]
            const newNode = this.getNewNode(pType, pId, level)
            const { type, mock } = this.analysisValue(curVal, attr)
            newNode.type = type
            newNode.attr = attr
            newNode.mock = mock
            configList.push(newNode)
            if (objectTypes.includes(type.value)) {
              loop(curVal, newNode.id, level)
            }
          }
        }
        else if (pType === 'array') {
          const allValueType = [ ...new Set(objOrArr.map(this.getTypeof)) ]
          for (const i in allValueType) {
            const curType = allValueType[i]
            const curVal = objOrArr.find(d => this.getTypeof(d) === curType)
            const newNode = this.getNewNode(pType, pId, level)
            const { type, mock } = this.analysisValue(curVal)
            newNode.type = type
            newNode.mock = mock
            configList.push(newNode)
            if (objectTypes.includes(type.value)) {
              loop(curVal, newNode.id, level)
            }
          }
        }
      }
      loop(json)
      const rootType = this.getTypeof(json)
      this.root.type = rootTypes.find(({ value }) => rootType === value)
      if (objectTypes.includes(rootType)) {
        this.root.mock = rootTypes.find(d => d.value === rootType).mockTip[0]
      }
      this.configList = configList
      configList.forEach(this.setDefMock)
    },
    onDragStart({ oldIndex }) {
      const list = this.configList
      const {
        type: { value: type },
        id,
      } = list[oldIndex]
      if (objectTypes.includes(type)) {
        const toMoveChildren = []
        const loop = ({ id }) => {
          this.configList
            .filter(({ pId }) => pId === id)
            .forEach(d => {
              const {
                id,
                type: { value: type },
              } = d
              toMoveChildren.push(d)
              if (objectTypes.includes(type)) {
                loop({ id })
              }
            })
        }
        loop({ id })
        const start = oldIndex + 1
        const length = toMoveChildren.length
        toMoveInfo = {
          start,
          length,
        }
        toMoveChildren.forEach(d => {
          d.toMove = true
        })
      }
    },
    onDragEnd({ newIndex, oldIndex }) {
      const list = this.configList
      let movedChildren
      const curNode = list[newIndex]
      const prevNode = newIndex ? list[newIndex - 1] : null
      if (toMoveInfo) {
        const { start, length } = toMoveInfo
        movedChildren = list.splice(oldIndex > newIndex ? start : start - 1, length)
        list.splice(newIndex + 1 - (newIndex > start ? length : 0), 0, ...movedChildren)
      }

      let newLevel = 0
      let levelChange = 0
      let newPid
      if (prevNode) {
        newLevel = prevNode.level
        newPid = prevNode.pId

        if (objectTypes.includes(prevNode.type.value)) {
          newLevel = prevNode.level + 1
          newPid = prevNode.id
        }
      }
      levelChange = newLevel - curNode.level
      curNode.level = newLevel
      curNode.pId = newPid
      if (!newLevel) {
        curNode.expanded = true
      }
      const {
        type: { value: pType },
      } = newPid ? list.find(({ id }) => id === newPid) : this.root
      if (pType === 'array') {
        curNode.noAttr = true
        curNode.attr = ''
      }
      else {
        curNode.noAttr = false
      }
      movedChildren?.forEach(d => {
        d.level += levelChange
        d.toMove = false
      })

      toMoveInfo = null
    },
    fireAdd(i) {
      const {
        type: { value: type },
      } = this.configList[i]
      this.addNewAttr(i, objectTypes.includes(type))
    },
    showMockTip,
  },
}
</script>

<style lang="scss" scoped>
.treeCtx {
  scroll-behavior: smooth;

  .toMove {
    opacity: 0.42;
    background: linear-gradient(90deg, #b3e5fc, #daf2fe, #ffffff);
  }

  .leftInput {
    &:deep( .q-field__control) {
      border-top-right-radius: 0!important;
      border-bottom-right-radius: 0!important;
    }
  }
  .rightInput {
    &:deep( .q-field__control) {
      border-top-left-radius: 0!important;
      border-bottom-left-radius: 0!important;

      &:before {
        border-left: 0!important;
      }
    }
  }
}
</style>

<style lang="scss" module>
.chosen {
  background-color: #b3e5fcff !important;
}

.ghost {
  background-color: #daf1fc !important;
}
</style>
